Exactly Once in verteilten Systemen: Realität oder Utopie?
Bei verteilten Systemen wird eine asynchrone Kommunikation häufig über einen Message Broker abgedeckt. Dadurch soll eine Entkopplung zwischen zwei Diensten erreicht werden, die unter Umständen separat skaliert werden können. Eine Kommunikation über einen Message Broker ist inhärent immer mindestens zweigeteilt, nämlich in Producer und Consumer.
Ein Producer erstellt dabei Nachrichten, wie in Abbildung 1 gezeigt, während ein Consumer sie verarbeitet.
Abb.1: Einfacher Nachrichtenaustausch
Der Message Broker ist häufig ein zustandsbehaftetes System – eine Art Datenbank – und vermittelt Nachrichten zwischen Producer und Consumer. Als zustandsbehaftetes System hat ein Message Broker die Aufgabe, Nachrichten vorzuhalten und abrufbar zu machen. Ein Producer schreibt also Nachrichten in den Broker, während ein Consumer sie zu einer beliebigen Zeit lesen kann. Exactly once, also einmaliges Ausliefern, bedeutet, dass der Producer genau eine Nachricht produziert und der Consumer diese genau einmal verarbeitet. Also ganz einfach, oder?
Stay tuned
Regelmäßig News zur Konferenz und der Java-Community erhalten
Es kann so einfach sein
Da eine Kommunikation zwischen den Systemen über eine Netzwerkebene stattfindet, ist nicht gewährleistet, dass die Systeme über den gleichen Wissensstand verfügen. Es muss also einen Rückkanal geben, der einzelne Operationen bestätigt, um einen Zustand zu teilen. Nur so wird sichergestellt, dass Nachrichten, die erstellt wurden, auch korrekt angekommen und verarbeitet worden sind. Aktualisiert sieht der Fluss also eher so aus, wie es Abbildung 2 zeigt.
Abb. 2: Durch Bestätigungen wird der Zustand zwischen Systemen geteilt
Erstellt ein Producer eine Nachricht, die vom Consumer gelesen werden soll, benötigt dieser eine Bestätigung (Abb. 2, Schritt 2). Nur dadurch weiß der Producer, dass die Nachricht korrekt im Broker persistiert vorliegt und er sie nicht erneut übertragen muss.
Der Consumer wiederum liest die Nachricht und verarbeitet sie. Ist die Verarbeitung fehlerfrei abgeschlossen, bestätigt der Consumer das. Der Broker muss die Nachricht deshalb nicht noch einmal ausliefern.
Immer Ärger mit der Kommunikation
Bei einer verteilten Kommunikation über ein Netzwerk kann es leider immer passieren, dass diverse Kommunikationskanäle abbrechen oder Fehler entstehen – und zwar an vielen Stellen: beim Erstellen, vor dem Konsumieren und danach. Genau diese Eigenschaft macht es so schwer oder gar unmöglich, eine Exactly-once-Semantik zu erreichen.
Angenommen, ein Producer produziert eine Nachricht (Abb. 3). Um sicherzustellen, dass diese auch vom Broker gespeichert wurde, wartet der Producer auf eine Bestätigung. Bleibt sie aus, ist nicht garantiert, dass der Broker diese Nachricht wirklich ausliefern wird.
Abb. 3: Der Producer erhält keine Bestätigung und sendet die Nachricht erneut
Der Producer muss diese Nachricht folglich erneut ausliefern. Genau das ist in Schritt 2 der Abbildung 3 auch geschehen, weshalb der Producer in Schritt 3 eine weitere Nachricht mit demselben Inhalt sendet. Da jetzt zwei Nachrichten vorliegen, verarbeitet der Consumer in den Schritt 4 und 5 beide Nachrichten – wohl eher nicht „exactly once“. Die Nachricht wird durch den Retry-Mechanismus „at least once“ – mindestens einmal, nicht genau einmal – übertragen. Denn wie im Bild zu erkennen ist, überträgt der Producer dieselbe Nachricht zweimal, um sicherzustellen, dass sie mindestens einmal vom Broker bestätigt wurde. Nur so ist sichergestellt, dass die Nachricht nicht verloren geht.
ALL ABOUT MICROSERVICES
Microservices-Track entdecken
Natürlich kann die Bestätigung auch ignoriert werden. Schritt 2 kann also ausbleiben. Ein Retry-System würde folglich fehlen. Der Producer überträgt also eine Nachricht, ohne auf eine Bestätigung des Brokers zu warten. Kann der Broker die Nachricht selbst nicht verarbeiten oder wegspeichern, hat er keine Möglichkeit, das Fehlschlagen oder eine erfolgreiche Operation zu quittieren. Die Nachricht würde „at most once“ – maximal einmal oder eben keinmal – übertragen werden. Exactly once ist also grundsätzlich ein Problem verteilter Anwendungen, die mittels Bestätigungen funktionieren.
Leider ist das noch nicht das Ende der Fahnenstange, wenn die Nachricht Ende zu Ende, also vom Producer bis zum Consumer, betrachtet wird. Denn es existiert in einem solchen System zusätzlich ein Consumer, der die Nachrichten wiederum einmalig verarbeiten muss. Selbst wenn garantiert wird, dass der Producer eine Nachricht einmalig erzeugt, ist ein einmaliges Verarbeiten nicht garantiert.
Abb. 4: Ein Consumer verarbeitet die Nachricht und versucht diese danach zu bestätigen
Es kann passieren, dass der Consumer wie in Abbildung 4 gezeigt die Nachricht in Schritt 3 liest und in Schritt 4 korrekt verarbeitet. In Schritt 5 geht die Bestätigung verloren. Das führt dazu, dass die Nachricht mehrmals, aber mindestens einmal – at least once – verarbeitet wird.
Abb. 5: Ein Consumer bestätigt die Nachricht vor dem Verarbeiten
Es ist natürlich umgekehrt auch möglich, die Nachricht vor dem Verarbeiten zu bestätigen. Der Consumer lädt also die Nachricht und bestätigt sie direkt. Erst dann wird in Schritt 5 von Abbildung 5 die Bearbeitung der Nachricht erfolgen. Schlägt jetzt die Bearbeitung fehl, ist die Nachricht in Schritt 4 bereits bestätigt worden und wird nicht erneut eingelesen. Die Nachricht wurde wieder maximal einmal oder keinmal – at most once – verarbeitet.
Wie also zu erkennen, ist es leicht, At-most-once- und At-least-once–Semantiken in den verschiedenen Konstellationen sowohl auf Producer- als auch auf der Consumer-Seite herzustellen. Exactly once ist aber aufgrund der verteilten Systematik ein schwieriges Problem – oder gar unmöglich?
SIE LIEBEN JAVA?
Den Core-Java-Track entdecken
Lösungen müssen her
Für eine Möglichkeit, eine Exactly-once-Semantik zu erreichen, muss die Verarbeitung der Nachrichten einer Applikation eine bestimmte Eigenschaft unterstützen: Idempotenz. Idempotenz bedeutet, dass eine Operation, egal wie oft sie verarbeitet wird, immer dasselbe Ergebnis zur Folge hat. Ein Beispiel dieses Prinzips könnte das Setzen einer Variablen im Programmcode sein. Hier gibt es etwa die Möglichkeit, dies über Setter oder eben relative Mutationen zu implementieren.
Zum Beispiel setAge oder incrementAge. Die Operation person.setAge(14); kann beliebig oft nacheinander ausgeführt werden, das Ergebnis bleibt immer dasselbe, nämlich 14. Hingegen wäre person.incrementAge(1) nicht idempotent. Wird diese Methode unterschiedlich oft hintereinander ausgeführt, gibt es verschiedene Ergebnisse, nämlich nach jeder Ausführung ein Jahr mehr. Genau diese Eigenschaft der Idempotenz ist der Schlüssel, um eine Exactly-once-Semantik zu etablieren.
Angewandt auf die Systeme von zuvor bedeutet das, dass eine At-least-once-Semantik mit der Eigenschaft der Idempotenz zu einer Exactly-once-Verarbeitung führen kann. Wie eine At-least-once-Semantik umgesetzt werden kann, zeigt das zuvor beschriebene Bestätigungssystem. Was fehlt, ist also ein System von Idempotenz in der Verarbeitung. Aber wie kann eine Verarbeitung von Nachrichten idempotent gemacht werden?
Um das zu erreichen, muss der Consumer die Möglichkeit haben, einen lokalen, synchronisierten Zustand zu erhalten. Um den Zustand einer Nachricht zu erhalten, muss diese eindeutig identifizierbar sein. Nur so werden das Aufsuchen und eine Deduplizierung der Nachricht ermöglicht.
Abb. 6: Eine idempotente Verarbeitung
Anders als zuvor speichert der Consumer mit jedem Aufruf in Schritt 4 der Abbildung 6 die Nachricht zunächst in einer lokalen Zustandshaltung. An dieser Stelle kann, sofern die Nachricht bereits lokal vorhanden ist, ein erneutes Speichern vernachlässigt werden. In Schritt 5 wird die Nachricht bestätigt. Schlägt die Bestätigung fehl und wird die Nachricht folglich erneut übertragen, ist das kein Problem, da in Schritt 4 das erneute Speichern der Nachricht verhindert werden kann. An dieser Stelle lebt also die Idempotenz. Beim Bearbeiten kann der Consumer nun selbst entscheiden, ob eine Verarbeitung notwendig ist, z. B. indem zu einer Nachricht ein Status eingeführt und dieser in Schritt 6 lokal abgefragt wird. Steht dieser bereits auf Processed, muss nichts getan werden. Umgekehrt muss eine verarbeitete Nachricht den Status korrekt aktualisieren.
Stay tuned
Regelmäßig News zur Konferenz und der Java-Community erhalten
Fazit
Verteilte Systeme haben ein grundsätzliches Problem, eine Exactly-once-Semantik herzustellen. Es kann auf Infrastrukturebene entweder zwischen at least once oder at most once gewählt werden. Erst durch die Eigenschaft der Idempotenz kann auf dem Applikationslevel sichergestellt werden, dass Nachrichten genau einmal von Ende zu Ende verarbeitet werden.
Natürlich ist das nicht kostenlos. Es bedeutet, dass die Applikation selbst eine Verwaltung von Nachrichten übernehmen muss und deren Zustand verwaltet – wirklich exactly once ist das natürlich auch nicht, es kommt diesem durch die Eigenschaft der Idempotenz jedoch im Ergebnis sehr nahe.
Lars Kölpin-Freese ist Fullstack-Softwareentwickler bei der bitside GmbH. Zu seinen Leidenschaften gehört das Planen und Entwickeln von verteilten Systemen – am liebsten in der Cloud. Er besitzt mehrere Jahre Erfahrung im Java-Enterprise-Umfeld sowie mit dem Entwickeln von Single Page Applications mit TypeScript. Für den Betrieb von Software hat er sich auf Containertechnologien und Kubernetes spezialisiert.